Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 29d91785fb3880c7677b4e31da560d95993ae089


Parents : 1184fa4
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-09T13:49:56-05:00

feat(call-management): update incoming call handling by implementing identity hash resolution for contacts, improving call rejection logic, and adding support for LXMF and LXST address matching in contact lookups

Changes
Diff

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 34fcff19..ff3d83da 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -4018,9 +4018,17 @@ class ReticulumMeshChat:
return
if ctx.telephone_manager and ctx.telephone_manager.initiation_status:
+ # Outgoing dial owns the line. Reject the inbound caller so they are
+ # not left ringing while we ignore the callback locally.
print(
- "on_incoming_telephone_call: Ignoring as we are currently initiating an outgoing call.",
+ "on_incoming_telephone_call: Rejecting as we are currently initiating an outgoing call.",
)
+ telephone = getattr(ctx.telephone_manager, "telephone", None)
+ if telephone:
+ threading.Timer(
+ 0.5,
+ lambda t=telephone: t.hangup(),
+ ).start()
return
caller_hash = caller_identity.hash.hex()
@@ -4054,15 +4062,7 @@ class ReticulumMeshChat:
ctx.config.telephone_allow_calls_from_contacts_only.get()
or ctx.config.block_all_from_strangers.get()
):
- contact = None
- try:
- contact = ctx.database.contacts.get_contact_by_identity_hash(
- caller_hash
- )
- except Exception:
- # Treat lookup failure as non-contact to avoid accidentally allowing spam
- pass
- if not contact:
+ if not self._is_contact(caller_hash, context=ctx):
print(f"Rejecting incoming call from non-contact: {caller_hash}")
telephone = getattr(ctx.telephone_manager, "telephone", None)
if telephone:
@@ -4078,13 +4078,7 @@ class ReticulumMeshChat:
print(f"on_incoming_telephone_call: {caller_identity.hash.hex()}")
ch = caller_identity.hash.hex()
caller_name = (self.get_name_for_identity_hash(ch) or "").strip() or "Mesh"
- is_contact = False
- try:
- is_contact = (
- ctx.database.contacts.get_contact_by_identity_hash(ch) is not None
- )
- except Exception:
- pass
+ is_contact = self._is_contact(ch, context=ctx)
AsyncUtils.run_async(
self.websocket_broadcast(
json.dumps(
@@ -4181,16 +4175,11 @@ class ReticulumMeshChat:
is_filtered = False
if ctx.config.do_not_disturb_enabled.get():
is_filtered = True
- elif ctx.config.telephone_allow_calls_from_contacts_only.get():
- try:
- contact = ctx.database.contacts.get_contact_by_identity_hash(
- remote_identity_hash,
- )
- if not contact:
- is_filtered = True
- except Exception:
- # Treat lookup failure as filtered to avoid leaking missed-call noise
- is_filtered = True
+ elif (
+ ctx.config.telephone_allow_calls_from_contacts_only.get()
+ or ctx.config.block_all_from_strangers.get()
+ ) and not self._is_contact(remote_identity_hash, context=ctx):
+ is_filtered = True
if not is_filtered:
AsyncUtils.run_async(
@@ -9435,16 +9424,14 @@ class ReticulumMeshChat:
if self.config.do_not_disturb_enabled.get():
# Don't report active call if DND is on and it's ringing
telephone_active_call = None
- elif self.config.telephone_allow_calls_from_contacts_only.get():
+ elif (
+ self.config.telephone_allow_calls_from_contacts_only.get()
+ or self.config.block_all_from_strangers.get()
+ ):
remote_identity = telephone_active_call.get_remote_identity()
if remote_identity:
caller_hash = remote_identity.hash.hex()
- contact = (
- self.database.contacts.get_contact_by_identity_hash(
- caller_hash,
- )
- )
- if not contact:
+ if not self._is_contact(caller_hash):
# Don't report active call if contacts-only is on and caller is not a contact
telephone_active_call = None
else:
@@ -9483,9 +9470,7 @@ class ReticulumMeshChat:
remote_icon = self.database.misc.get_user_icon(lxmf_destination_hash)
# Check if contact and get custom image
- contact = self.database.contacts.get_contact_by_identity_hash(
- remote_hash,
- )
+ contact = self._resolve_contact_for_hash(remote_hash)
custom_image = contact["custom_image"] if contact else None
active_call = {
@@ -9573,11 +9558,9 @@ class ReticulumMeshChat:
initiation_target_name = None
if initiation_target_hash:
try:
- contact = self.database.contacts.get_contact_by_identity_hash(
- initiation_target_hash,
- )
+ contact = self._resolve_contact_for_hash(initiation_target_hash)
if contact:
- initiation_target_name = contact.name
+ initiation_target_name = contact["name"]
except Exception:
pass
@@ -9755,9 +9738,7 @@ class ReticulumMeshChat:
if tele_hash:
d["remote_telephony_hash"] = tele_hash
- contact = self.database.contacts.get_contact_by_identity_hash(
- remote_identity_hash,
- )
+ contact = self._resolve_contact_for_hash(remote_identity_hash)
d["is_contact"] = contact is not None
if contact:
d["contact_image"] = contact.get("custom_image")
@@ -10595,28 +10576,69 @@ class ReticulumMeshChat:
status=400,
)
- if not remote_identity_hash:
- # Try to derive identity from LXMF or LXST address
- lookup_hash = lxmf_address or lxst_address
- if lookup_hash:
- announce = self.database.announces.get_announce_by_hash(lookup_hash)
- if announce:
- remote_identity_hash = announce.get("identity_hash")
- else:
- # try to recall identity from RNS
- ident = self.recall_identity(lookup_hash)
- if ident:
- remote_identity_hash = ident.hash.hex()
+ # Normalize: chat UI often posts an LXMF destination hash as
+ # remote_identity_hash. Prefer the real identity hash when known so
+ # incoming-call policy (identity hash) matches saved contacts.
+ provided_hash = remote_identity_hash
+ lookup_hash = remote_identity_hash or lxmf_address or lxst_address
+ if lookup_hash:
+ announce = self.database.announces.get_announce_by_hash(lookup_hash)
+ if announce and announce.get("identity_hash"):
+ remote_identity_hash = announce.get("identity_hash")
+ if not lxmf_address and announce.get("aspect") == "lxmf.delivery":
+ lxmf_address = announce.get("destination_hash") or lookup_hash
+ if not lxst_address and announce.get("aspect") == "lxst.telephony":
+ lxst_address = announce.get("destination_hash") or lookup_hash
+ else:
+ ident = self.recall_identity(lookup_hash)
+ if ident:
+ remote_identity_hash = ident.hash.hex()
if not remote_identity_hash:
- # Fallback: use the provided lookup hash directly as identity hash
- remote_identity_hash = lxmf_address or lxst_address
+ remote_identity_hash = lxmf_address or lxst_address or provided_hash
if not remote_identity_hash:
return web.json_response(
{"message": "Identity hash is required or could not be derived"},
status=400,
)
+ # If the client only supplied a destination hash, keep it on the
+ # matching address field so lookups by either form succeed.
+ if provided_hash and provided_hash != remote_identity_hash:
+ if not lxmf_address:
+ lxmf_announce = self.database.announces.get_announce_by_hash(
+ provided_hash,
+ )
+ if lxmf_announce and lxmf_announce.get("aspect") == "lxmf.delivery":
+ lxmf_address = provided_hash
+ elif not lxst_address:
+ lxst_announce = self.database.announces.get_announce_by_hash(
+ provided_hash,
+ )
+ if (
+ lxst_announce
+ and lxst_announce.get("aspect") == "lxst.telephony"
+ ):
+ lxst_address = provided_hash
+ else:
+ # Default: treat unknown destination-shaped hashes as LXMF
+ lxmf_address = lxmf_address or provided_hash
+
+ if not lxmf_address:
+ try:
+ lxmf_address = self.get_lxmf_destination_hash_for_identity_hash(
+ remote_identity_hash,
+ )
+ except Exception:
+ pass
+ if not lxst_address:
+ try:
+ lxst_address = self.get_lxst_telephony_hash_for_identity_hash(
+ remote_identity_hash,
+ )
+ except Exception:
+ pass
+
self.database.contacts.add_contact(
name,
remote_identity_hash,
@@ -10663,7 +10685,7 @@ class ReticulumMeshChat:
@routes.get("/api/v1/telephone/contacts/check/{identity_hash}")
async def telephone_contacts_check(request):
identity_hash = request.match_info["identity_hash"]
- contact = self.database.contacts.get_contact_by_identity_hash(identity_hash)
+ contact = self._resolve_contact_for_hash(identity_hash)
return web.json_response(
{
"is_contact": contact is not None,
@@ -19091,15 +19113,82 @@ class ReticulumMeshChat:
background_colour,
)
- def _is_contact(self, source_hash: str, context=None) -> bool:
+ def _related_hashes_for_contact_lookup(self, source_hash: str, context=None):
+ """Collect identity/LXMF/LXST hashes that may identify the same peer."""
ctx = context or self.current_context
+ related = []
+ seen = set()
+
+ def add(value):
+ if not value or not isinstance(value, str):
+ return
+ normalized = normalize_hex_identifier(value)
+ if not normalized or normalized in seen:
+ return
+ seen.add(normalized)
+ related.append(normalized)
+
+ add(source_hash)
if not ctx or not ctx.database:
- return False
+ return related
+
try:
- contact = ctx.database.contacts.get_contact_by_identity_hash(source_hash)
- return contact is not None
+ announce = ctx.database.announces.get_announce_by_hash(source_hash)
+ if announce:
+ add(announce.get("identity_hash"))
+ add(announce.get("destination_hash"))
+ identity_hash = announce.get("identity_hash")
+ if identity_hash:
+ for other in ctx.database.announces.get_announces_by_identity_hash(
+ identity_hash,
+ ):
+ add(other.get("destination_hash"))
+ add(other.get("identity_hash"))
+ else:
+ for other in ctx.database.announces.get_announces_by_identity_hash(
+ source_hash,
+ ):
+ add(other.get("destination_hash"))
+ add(other.get("identity_hash"))
except Exception:
- return False
+ pass
+
+ try:
+ lxmf_hash = self.get_lxmf_destination_hash_for_identity_hash(source_hash)
+ add(lxmf_hash)
+ except Exception:
+ pass
+
+ try:
+ lxst_hash = self.get_lxst_telephony_hash_for_identity_hash(source_hash)
+ add(lxst_hash)
+ except Exception:
+ pass
+
+ return related
+
+ def _resolve_contact_for_hash(self, source_hash: str, context=None):
+ """Resolve a contact for an identity or destination hash.
+
+ Contacts are often saved with an LXMF destination hash as
+ ``remote_identity_hash`` (from chat UI). Incoming calls provide the
+ caller's identity hash. Bridge those forms via announces and derived
+ destination hashes so contacts-only call policy works.
+ """
+ ctx = context or self.current_context
+ if not ctx or not ctx.database or not source_hash:
+ return None
+ try:
+ related = self._related_hashes_for_contact_lookup(source_hash, context=ctx)
+ return ctx.database.contacts.get_contact_by_identity_hash(
+ source_hash,
+ related_hashes=related,
+ )
+ except Exception:
+ return None
+
+ def _is_contact(self, source_hash: str, context=None) -> bool:
+ return self._resolve_contact_for_hash(source_hash, context=context) is not None
def _encode_pcm_wav_to_ogg_opus(self, wav_bytes: bytes) -> bytes | None:
"""Encode a WAV/PCM payload into an OGG/Opus byte string.

diff --git a/meshchatx/src/backend/database/contacts.py b/meshchatx/src/backend/database/contacts.py
index 171b1383..af3bbcf5 100644
--- a/meshchatx/src/backend/database/contacts.py
+++ b/meshchatx/src/backend/database/contacts.py
@@ -132,8 +132,38 @@ class ContactsDAO:
def delete_contact(self, contact_id):
self.provider.execute("DELETE FROM contacts WHERE id = ?", (contact_id,))
- def get_contact_by_identity_hash(self, remote_identity_hash):
+ def get_contact_by_identity_hash(self, remote_identity_hash, related_hashes=None):
+ """Match a contact by identity, LXMF, or LXST hash.
+
+ ``related_hashes`` may include derived destination hashes for the same
+ peer so callers that only know an identity hash still match contacts
+ that were saved with an LXMF or LXST destination hash as the primary key.
+ Matching is case-insensitive. Hex-only forms are also tried so UUID-style
+ separators still match stored RNS hashes.
+ """
+ if not remote_identity_hash and not related_hashes:
+ return None
+
+ candidates = []
+ for value in (remote_identity_hash, *(related_hashes or ())):
+ if not value or not isinstance(value, str):
+ continue
+ lowered = value.strip().lower()
+ if lowered and lowered not in candidates:
+ candidates.append(lowered)
+ hex_only = "".join(c for c in lowered if c in "0123456789abcdef")
+ if hex_only and hex_only not in candidates:
+ candidates.append(hex_only)
+ if not candidates:
+ return None
+
+ placeholders = ", ".join("?" for _ in candidates)
return self.provider.fetchone(
- "SELECT * FROM contacts WHERE remote_identity_hash = ? OR lxmf_address = ? OR lxst_address = ?",
- (remote_identity_hash, remote_identity_hash, remote_identity_hash),
+ f"""
+ SELECT * FROM contacts
+ WHERE lower(remote_identity_hash) IN ({placeholders})
+ OR lower(lxmf_address) IN ({placeholders})
+ OR lower(lxst_address) IN ({placeholders})
+ """,
+ tuple(candidates) * 3,
)

diff --git a/meshchatx/src/backend/voicemail_manager.py b/meshchatx/src/backend/voicemail_manager.py
index 5c85ce63..29b7c6aa 100644
--- a/meshchatx/src/backend/voicemail_manager.py
+++ b/meshchatx/src/backend/voicemail_manager.py
@@ -171,7 +171,23 @@ class VoicemailManager:
RNS.log("Voicemail: Voicemail is disabled", RNS.LOG_DEBUG)
return
- if self.db and self.db.misc.is_destination_blocked(caller_identity.hash.hex()):
+ caller_hash = caller_identity.hash.hex()
+ is_blocked = False
+ if self.db:
+ try:
+ if self.db.misc.is_destination_blocked(caller_hash):
+ is_blocked = True
+ else:
+ for ann in self.db.announces.get_announces_by_identity_hash(
+ caller_hash,
+ ):
+ dest = ann.get("destination_hash")
+ if dest and self.db.misc.is_destination_blocked(dest):
+ is_blocked = True
+ break
+ except Exception:
+ is_blocked = False
+ if is_blocked:
RNS.log(
f"Voicemail: Caller {RNS.prettyhexrep(caller_identity.hash)} is blocked; skipping auto-answer",
RNS.LOG_DEBUG,

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 0e334beb..97159768 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -1267,7 +1267,11 @@ export default {
if (this.config?.do_not_disturb_enabled) {
return;
}
- if (this.config?.telephone_allow_calls_from_contacts_only && !json.is_contact) {
+ if (
+ (this.config?.telephone_allow_calls_from_contacts_only ||
+ this.config?.block_all_from_strangers) &&
+ !json.is_contact
+ ) {
return;
}
if (this.initiationStatus) {

diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index d3551935..00af0f74 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -2422,6 +2422,8 @@ export default {
contactForm: {
name: "",
remote_identity_hash: "",
+ lxmf_address: "",
+ lxst_address: "",
},
searchDebounceTimeout: null,
isVoicemailSettingsExpanded: false,
@@ -3585,8 +3587,9 @@ export default {
this.editingContact = null;
this.contactForm = {
name: entry.remote_identity_name || "",
- remote_identity_hash:
- entry.remote_telephony_hash || entry.remote_destination_hash || entry.remote_identity_hash,
+ remote_identity_hash: entry.remote_identity_hash || "",
+ lxmf_address: entry.remote_destination_hash || "",
+ lxst_address: entry.remote_telephony_hash || "",
preferred_ringtone_id: null,
};
this.isContactModalOpen = true;
@@ -4245,22 +4248,32 @@ export default {
}
let hashToCall = identityHash.trim();
- // Accept lxmf:// URIs or pasted text; extract first 64-char hex
- const hexMatch = hashToCall.match(/[0-9a-fA-F]{64}/);
+ // Accept lxmf:// URIs or pasted text; RNS truncated hashes are 32 hex chars
+ const hexMatch = hashToCall.match(/[0-9a-fA-F]{32,64}/);
if (hexMatch) {
- hashToCall = hexMatch[0];
+ hashToCall = hexMatch[0].slice(0, 32);
}
hashToCall = hashToCall.toLowerCase();
// Try to resolve name from contacts
const contact = this.contacts.find((c) => c.name.toLowerCase() === hashToCall.toLowerCase());
if (contact) {
- hashToCall = contact.remote_identity_hash;
+ hashToCall =
+ contact.remote_identity_hash ||
+ contact.remote_telephony_hash ||
+ contact.lxst_address ||
+ contact.lxmf_address;
}
// Provide immediate feedback
this.destinationHash = hashToCall;
- const targetContact = this.contacts.find((c) => c.remote_identity_hash === hashToCall);
+ const targetContact = this.contacts.find(
+ (c) =>
+ c.remote_identity_hash === hashToCall ||
+ c.lxmf_address === hashToCall ||
+ c.lxst_address === hashToCall ||
+ c.remote_telephony_hash === hashToCall
+ );
this.initiationTargetHash = hashToCall;
this.initiationTargetName = targetContact ? targetContact.name : null;
this.activeTab = "phone";

diff --git a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue
index 1577759d..fc16302c 100644
--- a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue
@@ -228,7 +228,8 @@ export default {
// create contact first
await window.api.post("/api/v1/telephone/contacts", {
name: this.peer.display_name,
- remote_identity_hash: this.peer.destination_hash,
+ remote_identity_hash: this.peer.identity_hash || undefined,
+ lxmf_address: this.peer.destination_hash,
is_telemetry_trusted: true,
});
await this.fetchContact();
@@ -239,7 +240,7 @@ export default {
this.contact.is_telemetry_trusted = newStatus;
}
GlobalEmitter.emit("contact-updated", {
- remote_identity_hash: this.peer.destination_hash,
+ remote_identity_hash: this.peer.identity_hash || this.peer.destination_hash,
is_telemetry_trusted: newStatus,
});
DialogUtils.alert(

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 0a55623a..ace54d44 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -3143,11 +3143,12 @@ export default {
await window.api.post("/api/v1/telephone/contacts", {
name: displayName,
lxmf_address: hash,
+ remote_identity_hash: this.selectedPeer.identity_hash || undefined,
});
this.isStrangerPeer = false;
this.strangerBannerDismissed = true;
GlobalEmitter.emit("contact-updated", {
- remote_identity_hash: hash,
+ remote_identity_hash: this.selectedPeer.identity_hash || hash,
});
this.$emit("reload-conversations");
ToastUtils.success(this.$t("contacts.contact_added"));

diff --git a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
index 550ed355..d0100f1e 100644
--- a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
@@ -1275,13 +1275,13 @@ export default {
async toggleTelemetryTrust(hash) {
const contact = this.contextMenu.targetContact;
const newStatus = !contact?.is_telemetry_trusted;
+ const conv = this.conversations.find((c) => c.destination_hash === hash);
try {
if (!contact) {
- // find display name from conversations
- const conv = this.conversations.find((c) => c.destination_hash === hash);
await window.api.post("/api/v1/telephone/contacts", {
name: conv?.display_name || hash.substring(0, 8),
- remote_identity_hash: hash,
+ remote_identity_hash: conv?.identity_hash || undefined,
+ lxmf_address: hash,
is_telemetry_trusted: true,
});
} else {
@@ -1290,7 +1290,7 @@ export default {
});
}
GlobalEmitter.emit("contact-updated", {
- remote_identity_hash: hash,
+ remote_identity_hash: conv?.identity_hash || hash,
is_telemetry_trusted: newStatus,
});
this.contextMenu.show = false;

diff --git a/tests/backend/test_contact_hash_resolution.py b/tests/backend/test_contact_hash_resolution.py
new file mode 100644
index 00000000..8fb38a88
--- /dev/null
+++ b/tests/backend/test_contact_hash_resolution.py
@@ -0,0 +1,70 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Contact resolution across identity / LXMF / LXST hash forms."""
+
+from unittest.mock import MagicMock
+
+from meshchatx.meshchat import ReticulumMeshChat
+
+IDENTITY = "a1" * 16
+LXMF = "b2" * 16
+LXST = "c3" * 16
+
+
+def _app_with_db(contact_row=None, announces=None):
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ ctx = MagicMock()
+ ctx.database = MagicMock()
+ ctx.database.announces.get_announce_by_hash.return_value = None
+ ctx.database.announces.get_announces_by_identity_hash.return_value = announces or []
+ ctx.database.contacts.get_contact_by_identity_hash.return_value = contact_row
+ app.current_context = ctx
+ app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=LXMF)
+ app.get_lxst_telephony_hash_for_identity_hash = MagicMock(return_value=LXST)
+ return app
+
+
+def test_related_hashes_include_announced_and_derived_destinations():
+ app = _app_with_db(
+ announces=[
+ {
+ "destination_hash": LXMF,
+ "identity_hash": IDENTITY,
+ "aspect": "lxmf.delivery",
+ },
+ ],
+ )
+ related = app._related_hashes_for_contact_lookup(IDENTITY)
+ assert IDENTITY in related
+ assert LXMF in related
+ assert LXST in related
+
+
+def test_is_contact_true_when_saved_under_lxmf_primary_key():
+ contact = {"id": 1, "name": "Friend", "remote_identity_hash": LXMF}
+
+ def lookup(primary, related_hashes=None):
+ keys = {primary, *(related_hashes or ())}
+ if LXMF in keys:
+ return contact
+ return None
+
+ app = _app_with_db()
+ app.current_context.database.contacts.get_contact_by_identity_hash.side_effect = (
+ lookup
+ )
+ app.current_context.database.announces.get_announces_by_identity_hash.return_value = [
+ {
+ "destination_hash": LXMF,
+ "identity_hash": IDENTITY,
+ "aspect": "lxmf.delivery",
+ },
+ ]
+
+ assert app._is_contact(IDENTITY) is True
+ assert app._resolve_contact_for_hash(IDENTITY)["name"] == "Friend"
+
+
+def test_is_contact_false_for_unknown_peer():
+ app = _app_with_db(contact_row=None)
+ assert app._is_contact(IDENTITY) is False

diff --git a/tests/backend/test_contacts_display_name_semantics.py b/tests/backend/test_contacts_display_name_semantics.py
index ccdb74e3..36cf66bc 100644
--- a/tests/backend/test_contacts_display_name_semantics.py
+++ b/tests/backend/test_contacts_display_name_semantics.py
@@ -288,6 +288,24 @@ class TestContactsEdgeCases:
assert result is not None
assert result["name"] == "Via LXST"
+ def test_get_contact_by_identity_hash_case_insensitive(self, contacts_dao):
+ contacts_dao.add_contact("Case", "AABB" + "cc" * 14)
+ result = contacts_dao.get_contact_by_identity_hash("aabb" + "CC" * 14)
+ assert result is not None
+ assert result["name"] == "Case"
+
+ def test_get_contact_by_identity_hash_related_hashes(self, contacts_dao):
+ identity = "a1" * 16
+ lxmf = "b2" * 16
+ contacts_dao.add_contact("SavedAsLxmf", lxmf, lxmf_address=lxmf)
+ result = contacts_dao.get_contact_by_identity_hash(
+ identity,
+ related_hashes=[lxmf],
+ )
+ assert result is not None
+ assert result["name"] == "SavedAsLxmf"
+ assert result["remote_identity_hash"] == lxmf
+
def test_delete_nonexistent_contact(self, contacts_dao):
contacts_dao.delete_contact(99999)

diff --git a/tests/backend/test_incoming_call_policy.py b/tests/backend/test_incoming_call_policy.py
index 9b8df110..172fa013 100644
--- a/tests/backend/test_incoming_call_policy.py
+++ b/tests/backend/test_incoming_call_policy.py
@@ -9,6 +9,7 @@ import pytest
from meshchatx.meshchat import ReticulumMeshChat
CALLER_HASH_HEX = "a1" * 16
+LXMF_DEST_HEX = "b2" * 16
def _caller_identity():
@@ -27,10 +28,15 @@ def policy_app():
ctx.telephone_manager = tm
ctx.config = MagicMock()
ctx.database = MagicMock()
+ ctx.database.announces.get_announce_by_hash.return_value = None
+ ctx.database.announces.get_announces_by_identity_hash.return_value = []
ctx.voicemail_manager = MagicMock()
app.current_context = ctx
app.is_destination_blocked = MagicMock(return_value=False)
app.websocket_broadcast = MagicMock()
+ app.get_name_for_identity_hash = MagicMock(return_value="Caller")
+ app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=None)
+ app.get_lxst_telephony_hash_for_identity_hash = MagicMock(return_value=None)
return app
@@ -56,8 +62,8 @@ def test_incoming_rejects_when_blocked_uses_delayed_hangup(policy_app):
_run_incoming(policy_app, caller)
- policy_app.telephone_manager.telephone.hangup.assert_called_once()
- policy_app.voicemail_manager.handle_incoming_call.assert_not_called()
+ policy_app.current_context.telephone_manager.telephone.hangup.assert_called_once()
+ policy_app.current_context.voicemail_manager.handle_incoming_call.assert_not_called()
def test_incoming_dnd_rejects_before_contact_check(policy_app):
@@ -115,9 +121,9 @@ def test_contacts_only_rejects_non_contact_uses_identity_lookup(policy_app):
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller)
- policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_once_with(
- CALLER_HASH_HEX,
- )
+ policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called()
+ call_kwargs = policy_app.current_context.database.contacts.get_contact_by_identity_hash.call_args
+ assert call_kwargs[0][0] == CALLER_HASH_HEX
policy_app.current_context.telephone_manager.telephone.hangup.assert_called_once()
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_not_called()
async_utils.run_async.assert_not_called()
@@ -139,13 +145,10 @@ def test_contacts_only_accepts_matching_contact(policy_app):
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller)
- # Called twice: once for policy check, once for websocket broadcast is_contact flag
- policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_with(
- CALLER_HASH_HEX,
- )
+ policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called()
assert (
policy_app.current_context.database.contacts.get_contact_by_identity_hash.call_count
- == 2
+ >= 1
)
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(
caller
@@ -154,6 +157,42 @@ def test_contacts_only_accepts_matching_contact(policy_app):
async_utils.run_async.assert_called_once()
+def test_contacts_only_accepts_contact_saved_under_lxmf_destination(policy_app):
+ """Chat UI often stores LXMF destination hash as remote_identity_hash."""
+ policy_app.is_destination_blocked.return_value = False
+ policy_app.config.do_not_disturb_enabled.get.return_value = False
+ policy_app.config.telephone_allow_calls_from_contacts_only.get.return_value = True
+ policy_app.config.block_all_from_strangers.get.return_value = False
+ policy_app.current_context.database.announces.get_announces_by_identity_hash.return_value = [
+ {
+ "destination_hash": LXMF_DEST_HEX,
+ "identity_hash": CALLER_HASH_HEX,
+ "aspect": "lxmf.delivery",
+ },
+ ]
+ policy_app.get_lxmf_destination_hash_for_identity_hash.return_value = LXMF_DEST_HEX
+
+ def lookup(primary, related_hashes=None):
+ keys = {primary, *(related_hashes or ())}
+ if LXMF_DEST_HEX in keys or CALLER_HASH_HEX in keys:
+ return {"id": 1, "name": "Friend", "remote_identity_hash": LXMF_DEST_HEX}
+ return None
+
+ policy_app.current_context.database.contacts.get_contact_by_identity_hash.side_effect = lookup
+
+ caller = _caller_identity()
+
+ with patch("meshchatx.meshchat.AsyncUtils") as async_utils:
+ async_utils.run_async = MagicMock()
+ _run_incoming(policy_app, caller)
+
+ policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(
+ caller
+ )
+ policy_app.current_context.telephone_manager.telephone.hangup.assert_not_called()
+ async_utils.run_async.assert_called_once()
+
+
def test_block_all_strangers_uses_same_contact_gate(policy_app):
policy_app.is_destination_blocked.return_value = False
policy_app.config.do_not_disturb_enabled.get.return_value = False
@@ -177,9 +216,7 @@ def test_block_all_strangers_uses_same_contact_gate(policy_app):
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller)
- policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_once_with(
- CALLER_HASH_HEX,
- )
+ policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called()
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_not_called()
@@ -188,6 +225,7 @@ def test_when_policy_off_stranger_rings(policy_app):
policy_app.config.do_not_disturb_enabled.get.return_value = False
policy_app.config.telephone_allow_calls_from_contacts_only.get.return_value = False
policy_app.config.block_all_from_strangers.get.return_value = False
+ policy_app.current_context.database.contacts.get_contact_by_identity_hash.return_value = None
caller = _caller_identity()
@@ -195,24 +233,32 @@ def test_when_policy_off_stranger_rings(policy_app):
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller)
- # Called once for websocket broadcast is_contact flag even when policy is off
- policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_once_with(
- CALLER_HASH_HEX,
- )
+ policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called()
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(
caller
)
async_utils.run_async.assert_called_once()
-def test_ignores_incoming_while_outgoing_initiation(policy_app):
+def test_rejects_incoming_while_outgoing_initiation(policy_app):
policy_app.current_context.telephone_manager.initiation_status = "Dialing..."
caller = _caller_identity()
- with patch("meshchatx.meshchat.AsyncUtils") as async_utils:
- async_utils.run_async = MagicMock()
- _run_incoming(policy_app, caller)
+ with patch("meshchatx.meshchat.threading.Timer") as mock_timer:
+ def run_timer(delay, fn):
+ fn()
+ t = MagicMock()
+ t.start = MagicMock()
+ return t
+
+ mock_timer.side_effect = run_timer
+
+ with patch("meshchatx.meshchat.AsyncUtils") as async_utils:
+ async_utils.run_async = MagicMock()
+ _run_incoming(policy_app, caller)
+
+ policy_app.current_context.telephone_manager.telephone.hangup.assert_called_once()
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_not_called()
async_utils.run_async.assert_not_called()
@@ -224,14 +270,14 @@ def test_uses_passed_context_not_app_current_context(policy_app):
policy_app.config.telephone_allow_calls_from_contacts_only.get.return_value = True
policy_app.config.block_all_from_strangers.get.return_value = False
- # current_context has no contact
policy_app.current_context.database.contacts.get_contact_by_identity_hash.return_value = None
- # But a different ctx passed to the method DOES have the contact
other_ctx = MagicMock()
other_ctx.telephone_manager = policy_app.current_context.telephone_manager
other_ctx.config = policy_app.config
other_ctx.database = MagicMock()
+ other_ctx.database.announces.get_announce_by_hash.return_value = None
+ other_ctx.database.announces.get_announces_by_identity_hash.return_value = []
other_ctx.database.contacts.get_contact_by_identity_hash.return_value = {
"id": 1,
"name": "Friend",
@@ -244,10 +290,6 @@ def test_uses_passed_context_not_app_current_context(policy_app):
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller, ctx=other_ctx)
- # Called twice: once for policy check, once for websocket broadcast is_contact flag
- other_ctx.database.contacts.get_contact_by_identity_hash.assert_called_with(
- CALLER_HASH_HEX,
- )
- assert other_ctx.database.contacts.get_contact_by_identity_hash.call_count == 2
+ other_ctx.database.contacts.get_contact_by_identity_hash.assert_called()
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_not_called()
other_ctx.voicemail_manager.handle_incoming_call.assert_called_once_with(caller)

diff --git a/tests/backend/test_voicemail_manager_extended.py b/tests/backend/test_voicemail_manager_extended.py
index 4e83f17d..7f01c50e 100644
--- a/tests/backend/test_voicemail_manager_extended.py
+++ b/tests/backend/test_voicemail_manager_extended.py
@@ -201,6 +201,27 @@ def test_voicemail_session_sets_active_flag(mock_deps, temp_dir):
assert mock_tel_manager.is_voicemail_session_active is True
+def test_handle_incoming_skips_when_lxmf_destination_blocked(mock_deps, temp_dir):
+ mock_db = MagicMock()
+ mock_config = MagicMock()
+ mock_config.voicemail_enabled.get.return_value = True
+ mock_tel_manager = MagicMock()
+ vm = VoicemailManager(mock_db, mock_config, mock_tel_manager, temp_dir)
+
+ caller_hash = "a1" * 16
+ lxmf_dest = "b2" * 16
+ mock_caller = MagicMock()
+ mock_caller.hash = bytes.fromhex(caller_hash)
+ mock_db.misc.is_destination_blocked.side_effect = lambda h: h == lxmf_dest
+ mock_db.announces.get_announces_by_identity_hash.return_value = [
+ {"destination_hash": lxmf_dest, "identity_hash": caller_hash},
+ ]
+
+ with patch("threading.Thread") as mock_thread:
+ vm.handle_incoming_call(mock_caller)
+ mock_thread.assert_not_called()
+
+
def test_stop_recording_clears_active_flag(mock_deps, temp_dir):
mock_db = MagicMock()
mock_config = MagicMock()

diff --git a/tests/frontend/CallPage.test.js b/tests/frontend/CallPage.test.js
index bd51e1ea..73a893d8 100644
--- a/tests/frontend/CallPage.test.js
+++ b/tests/frontend/CallPage.test.js
@@ -260,6 +260,28 @@ describe("CallPage.vue", () => {
});
});
+ it("call extracts 32-char RNS hash from pasted text", async () => {
+ const wrapper = mountCallPage();
+ await wrapper.vm.$nextTick();
+ const hash32 = "ab".repeat(16);
+ await wrapper.vm.call(`Call me at ${hash32} please`);
+ expect(axiosMock.get).toHaveBeenCalledWith(`/api/v1/telephone/call/${hash32}`);
+ });
+
+ it("addContactFromHistory prefers identity hash over destination hashes", async () => {
+ const wrapper = mountCallPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.addContactFromHistory({
+ remote_identity_name: "Sam",
+ remote_identity_hash: "aa".repeat(16),
+ remote_destination_hash: "bb".repeat(16),
+ remote_telephony_hash: "cc".repeat(16),
+ });
+ expect(wrapper.vm.contactForm.remote_identity_hash).toBe("aa".repeat(16));
+ expect(wrapper.vm.contactForm.lxmf_address).toBe("bb".repeat(16));
+ expect(wrapper.vm.contactForm.lxst_address).toBe("cc".repeat(16));
+ });
+
it("toggleTelephoneAnnounceEnabled patches config", async () => {
const wrapper = mountCallPage();
await wrapper.vm.$nextTick();


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────